Skip to content

fix: fail fast on endpoint response stalls - #462

Merged
roborluo merged 1 commit into
mlcommons:mainfrom
roborluo:fix-bofengl-no-progress-watchdog
Sep 2, 2026
Merged

fix: fail fast on endpoint response stalls#462
roborluo merged 1 commit into
mlcommons:mainfrom
roborluo:fix-bofengl-no-progress-watchdog

Conversation

@roborluo

@roborluo roborluo commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add an opt-in no-progress deadline for in-flight endpoint requests
  • expose it as settings.timeouts.no_progress_timeout_s plus a --no-progress-timeout CLI alias
  • arm the guard only while work is in flight; retire it as soon as the phase drains

When this is useful

Use this for automated runs where a request can be accepted but the endpoint then becomes silent — for example, a TensorRT-LLM disaggregated executor or KV-transfer stall that never reaches the normal terminal-error path. It is engine-agnostic: it catches the same client-visible silent failure through vLLM, a frontend, or transport. Without it the benchmark blocks until its outer wall-time limit, because the phase drain waits on responses that never arrive and drain_timeout defaults to unlimited.

Disabled by default. It starts after work is issued, runs only while requests are in flight, and resets on an observed stream chunk or final result. It does not diagnose or restart the backend — it makes the benchmark fail with a clear error. For non-streaming endpoints, configure it above the full expected request latency. For TensorRT-LLM disaggregated serving the documented starting value is 300 s, matching the executor hang_detection_timeout.

Changes since the last review

Rebased onto latest main and moved the setting into the Timeouts model added by #409 (@viraatc).

Review point Change
[P1] completed_uuids retained every completed UUID Only register_skipped adds tombstones now, so it no longer grows O(total requests) alongside uuid_to_index
[P2] disabled path did work per response Gated on the feature being enabled, and the receiver reuses the arrival timestamp it already takes instead of reading the clock twice
[P2] task + timer per 0→1 transition Single self-rearming loop.call_later TimerHandle — no task, no Event, no per-iteration wait_for
[P2] full-timeout sleep could detect silence ~2x late Remaining deadline is computed from the last progress stamp
cohort wording Removed from the docs and from the local variable in issue()
help/description drift Schema description is the short form; docs/config/DESIGN.md holds the tuning detail

Measured cost when disabled (A/B against the base commit): +3.5 ns per response, +9.4 ns per request. With stream_all_chunks: false (the default) the main process sees 2 messages per request, not one per token.

Measured cost of the arming change under the production eager task factory: arm+cancel per request went from 2205 ns to 535 ns (4.1x).

Also fixed while here

A receiver transport error could overwrite an earlier NoProgressError. A stalled endpoint often drops its connection too, so the real diagnosis was being masked by a generic "receiver failed" message. First error now wins, with a regression test.

Not adopted

The suggestion to use one phase/session-lifetime watchdog driven by an activity event. The guard is still armed on the 0→1 in-flight transition and retired on drain — now with a TimerHandle rather than a task. This keeps the timer heap clear once a phase drains, and measured 4.1x cheaper than the reviewed version. Happy to switch to a resident watchdog if that is preferred.

Validation

  • pytest tests/unit/config/test_schema.py tests/unit/commands/test_benchmark.py tests/unit/load_generator/test_async_session.py425 passed
  • pre-commit run --all-files — all hooks pass except mypy, which reports 3 pre-existing os.sched_*affinity errors on macOS in cpu_affinity.py / token_metrics.py; neither file is touched by this PR and the symbols exist on Linux
  • python scripts/regenerate_templates.py --check
  • AGA disaggregated held-response canary, job 578946: failed as intended with Endpoint made no response progress for 10.0s with 1 request(s) in flight, 10.002 s after the phase started against a 10 s deadline. The 10 s value is fault-injection coverage, not the deployment recommendation.

Timer lifecycle is covered in both directions: the perf issue cap (stop_current_phase) deliberately leaves the guard armed so a stall during drain is still caught, while drain completion, stop(), a phase change, and session teardown each retire it.

@roborluo
roborluo requested a review from a team August 22, 2026 17:29
@github-actions

Copy link
Copy Markdown

MLCommons CLA bot All contributors have signed the MLCommons CLA ✍️ ✅

@github-actions github-actions Bot added the size/normal PR Review Policy: <=500 non-test lines & <=20 files label Aug 22, 2026
@codecov-commenter

codecov-commenter commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.87640% with 1 line in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@8259f8c). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/inference_endpoint/load_generator/session.py 98.75% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #462   +/-   ##
=======================================
  Coverage        ?   80.81%           
=======================================
  Files           ?      153           
  Lines           ?    21043           
  Branches        ?        0           
=======================================
  Hits            ?    17005           
  Misses          ?     4038           
  Partials        ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@roborluo
roborluo force-pushed the fix-bofengl-no-progress-watchdog branch from 02eeec7 to a5b80ac Compare August 24, 2026 17:18
@roborluo
roborluo requested a review from viraatc August 24, 2026 17:39
Comment thread docs/load_generator/DESIGN.md Outdated
Comment thread src/inference_endpoint/config/schema.py Outdated

@arekay-nv arekay-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The opt-in liveness guard is useful, but this implementation adds avoidable work and state to the load generator's hot paths. The most serious issue is retaining every completed UUID in a second phase-lifetime container, which is an O(total requests) memory regression at the repository's 50k+ QPS target. The disabled path also performs work for every response, and low-concurrency workloads can create/cancel a watchdog task per request. Please keep the disabled path inert and use bounded active-request state plus a single event/deadline-driven watchdog.

Comment thread src/inference_endpoint/load_generator/session.py Outdated
Comment thread src/inference_endpoint/load_generator/session.py Outdated
Comment thread src/inference_endpoint/load_generator/session.py Outdated
Comment thread src/inference_endpoint/load_generator/session.py Outdated
Comment thread src/inference_endpoint/config/schema.py Outdated
@roborluo
roborluo force-pushed the fix-bofengl-no-progress-watchdog branch from a5b80ac to 6d6f42a Compare August 27, 2026 00:46
@roborluo

Copy link
Copy Markdown
Collaborator Author

Thanks for the comments @viraatc and @arekay-nv . All issues resovled other than one.

For the per transition watchdog, I choosed a different approche than your suggested ,so flagging it here.

the guard is still armed on the 0→1 in-flight transition and retired on drain — but it's now a single loop.call_later TimerHandle instead of an asyncio task. No task, no Event, no per-iteration `wait_for.

Also measured arm+cancel per request, under the eager task factory this repo uses in production:

Task (reviewed version): 2205 ns
Timer (now): 535 ns → 4.1x

It also leaves the timer heap clear once a phase drains, which covers the "stale timer work" part of your comment.

let me know if that make sense to you, Thanks a lot!

@viraatc viraatc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Council: three actionable findings.

Comment thread src/inference_endpoint/load_generator/session.py Outdated
Comment thread src/inference_endpoint/load_generator/session.py
Comment thread docs/config/DESIGN.md Outdated
Comment thread docs/CLI_QUICK_REFERENCE.md Outdated

@arekay-nv arekay-nv left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this!
Can you add a test that verifies the behavior for one long stuck request while other requests are making progress. From my understanding, this will still wait till all the requests are completed except the stuck one (regardless of how long it stays stuck), and then the timer will trigger after the specified timeout. If that is the case (and correct me if i misunderstood), it seems like overkill to track progress.
Also, can you clarify the phase data being cleared on timeout is intentional.

Comment thread docs/load_generator/DESIGN.md Outdated
Comment thread src/inference_endpoint/commands/benchmark/execute.py
@roborluo

roborluo commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for putting this! Can you add a test that verifies the behavior for one long stuck request while other requests are making progress. From my understanding, this will still wait till all the requests are completed except the stuck one (regardless of how long it stays stuck), and then the timer will trigger after the specified timeout. If that is the case (and correct me if i misunderstood), it seems like overkill to track progress. Also, can you clarify the phase data being cleared on timeout is intentional.

Yes, that is intentional: this is a session-level liveness guard, not a per-request deadline. While other requests are making progress, the session is still live; once they drain, the remaining stuck request is the only in-flight work and the timeout fires. Tracking progress avoids falsely failing an active session. I’ll add a test for that scenario and clarify the docs.

@roborluo
roborluo force-pushed the fix-bofengl-no-progress-watchdog branch 6 times, most recently from 8184ecb to 2feebbb Compare September 2, 2026 04:35
Keep the guard disabled by default; recommend a 300-second or longer deadline when enabled. Preserve standard artifacts after a liveness failure and cover session-wide progress semantics.
@roborluo
roborluo force-pushed the fix-bofengl-no-progress-watchdog branch from 2feebbb to 7024848 Compare September 2, 2026 04:37
@roborluo
roborluo merged commit 3614f36 into mlcommons:main Sep 2, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/normal PR Review Policy: <=500 non-test lines & <=20 files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants